page.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { UNA_URL, UNA_API_KEY, MULTITENANT, PREVIEW_DOMAIN, appSetting } from 'app/config';
  2. import { decodeTextNode } from 'app/lib/util/html-text';
  3. import { cache } from 'react'
  4. import Root from 'app/root-client'
  5. import { Suspense } from 'react'
  6. import { notFound } from 'next/navigation'
  7. import { headers } from "next/headers"
  8. import { resolveLangCode } from '../../lib/resolve-lang'
  9. import { cachedUnaPageJson, unaPageCacheKey } from '../../lib/una-page-cache'
  10. const SITE_TITLE = appSetting('config', 'title');
  11. async function loadPage(props) {
  12. const [params, searchParams] = await Promise.all([
  13. props.params,
  14. props.searchParams,
  15. ]);
  16. return getData(params.path.join('/'), JSON.stringify(searchParams));
  17. }
  18. // Primitive arguments let metadata and page rendering share this request's fetch.
  19. const getData = cache(async (path, searchParamsKey) => {
  20. const search_params = JSON.parse(searchParamsKey);
  21. // Early check for static files — before any logs or UNA requests
  22. const staticFileExtensions = ['.map', '.js', '.css', '.json', '.png', '.jpg', '.svg', '.ico', '.woff', '.woff2', '.ttf'];
  23. const isStaticFile = staticFileExtensions.some(ext => path.endsWith(ext));
  24. if (isStaticFile || path.startsWith('_next/') || path.startsWith('static/')) {
  25. // Return 404 without logging or UNA requests
  26. return { data: { title: SITE_TITLE, description: SITE_TITLE }, code: 404 };
  27. }
  28. const hdrs = await headers();
  29. // The viewer's Cookie header exactly as the browser sent it — forwarded to UNA.
  30. const cookieString = hdrs.get('cookie') || '';
  31. // Tenant headers come from proxy.js only (it drops client-sent ones); trust them
  32. // only when the proxy can set them: multi-tenant mode or CI preview hosts.
  33. const trustTenantHeaders = MULTITENANT || !!PREVIEW_DOMAIN;
  34. const tenantUnaUrl = trustTenantHeaders ? hdrs.get('x-tenant-una-url') : null;
  35. const tenantUnaKey = trustTenantHeaders ? hdrs.get('x-tenant-una-key') : null;
  36. const fetchHeaders = {
  37. cookie: cookieString,
  38. authorization: 'Bearer ' + (tenantUnaKey ?? UNA_API_KEY),
  39. // The page is rendered for a NEO web viewer: UNA records site actions with this platform.
  40. 'x-neo-platform': 'web',
  41. };
  42. let l = tenantUnaUrl ?? UNA_URL;
  43. l += '/api.php' + '?r=system/get_page_by_request/TemplServicePages&params[]=' + path;
  44. const langCode = resolveLangCode(cookieString, hdrs?.get('accept-language') || '');
  45. if (langCode) {
  46. l += '&lang=' + encodeURIComponent(langCode);
  47. }
  48. let searchParams = { ...search_params };
  49. delete searchParams.cookieString; // legacy: old proxy put cookies in the URL
  50. delete searchParams.path;
  51. delete searchParams._rsc;
  52. let sBlocks = '';
  53. if (searchParams.blocks) {
  54. sBlocks = searchParams.blocks;
  55. }
  56. delete searchParams.blocks;
  57. if (sBlocks != '' && Object.keys(searchParams).length == 0) {
  58. l = l + '&params[]=' + sBlocks;
  59. }
  60. if (Object.keys(searchParams).length > 0) {
  61. l = l + '&params[]=' + sBlocks + '&params[]=' + JSON.stringify(searchParams);
  62. }
  63. const loadUnaPage = async () => {
  64. let res;
  65. try {
  66. res = await fetch(l, {
  67. headers: fetchHeaders,
  68. cache: 'no-store',
  69. })
  70. } catch (error) {
  71. console.error('Server fetch failed (network/connection):', error);
  72. return { data: { title: SITE_TITLE, description: SITE_TITLE }, code: 503 };
  73. }
  74. /* if (!res.ok) {
  75. const body = await res.text().catch(() => '');
  76. console.error('Server fetch failed (http):', res.status, body);
  77. return { data: { title: SITE_TITLE, description: SITE_TITLE }, code: res.status };
  78. }*/
  79. // Read the body once: a clone kept for the error branch buffered every page twice.
  80. let text = '';
  81. try {
  82. text = await res.text();
  83. return JSON.parse(text);
  84. } catch (error) {
  85. const fatal = text.match(/Fatal error[^<]*/i)?.[0]
  86. || text.match(/Uncaught [\w\\]+:[^\n<]+/)?.[0]
  87. || text.slice(0, 200);
  88. console.error('UNA page JSON parse failed:', fatal);
  89. return {
  90. data: { title: SITE_TITLE, description: SITE_TITLE, page_status: 503 },
  91. code: 500,
  92. };
  93. }
  94. };
  95. return cachedUnaPageJson({
  96. path,
  97. key: unaPageCacheKey({
  98. url: l,
  99. tenant: tenantUnaUrl ?? '',
  100. cookieString: cookieString || '',
  101. }),
  102. load: loadUnaPage,
  103. });
  104. });
  105. export const viewport = {
  106. width: 'device-width',
  107. initialScale: 1,
  108. //maximumScale: 1, // Prevent iOS auto-zoom on input focus
  109. viewportFit: 'cover',
  110. themeColor: [
  111. { media: '(prefers-color-scheme: light)', color: 'rgb(244,244,245)' },
  112. { media: '(prefers-color-scheme: dark)', color: 'rgb(9,9,11)' },
  113. ],
  114. }
  115. export async function generateMetadata(props) {
  116. const data = await loadPage(props);
  117. // UNA sends titles HTML-escaped; Next escapes metadata again, so the tab would read `&amp;`.
  118. const description = decodeTextNode(data?.data?.description || SITE_TITLE);
  119. const name = decodeTextNode(data?.data?.title || SITE_TITLE);
  120. const image = data?.data?.image;
  121. const isClientProject = UNA_URL != 'https://api.neo.so';
  122. const appleAppId = appSetting('smart_app_banner', 'apple_app_id');
  123. const appleAppArgument = appSetting('smart_app_banner', 'apple_app_argument');
  124. const other = {
  125. 'mobile-web-app-capable': 'yes',
  126. };
  127. if (appleAppId) {
  128. other['apple-itunes-app'] = appleAppArgument
  129. ? `app-id=${appleAppId}, app-argument=${appleAppArgument}`
  130. : `app-id=${appleAppId}`;
  131. }
  132. return {
  133. title: name,
  134. description: description,
  135. manifest: isClientProject ? '/static/manifest.json' : '/manifest.json',
  136. icons: {
  137. icon: isClientProject ? '/static/favicon.svg' : '/favicon.svg',
  138. },
  139. other,
  140. openGraph: {
  141. title: name,
  142. description: description,
  143. ...(image && {
  144. images: [
  145. {
  146. url: image,
  147. alt: name,
  148. },
  149. ],
  150. }),
  151. },
  152. twitter: {
  153. card: 'summary_large_image',
  154. title: name,
  155. description: description,
  156. ...(image && {
  157. images: [image],
  158. }),
  159. },
  160. }
  161. }
  162. export default async function Page(props) {
  163. const params = await props.params;
  164. const path = params?.path?.join('/') || '';
  165. const isHomePage = path === '' || path === 'home' || path === 'index';
  166. const data = await loadPage(props);
  167. // Static file 404 — call notFound immediately
  168. if (data?.code === 404 && !isHomePage) {
  169. notFound();
  170. }
  171. // Handle API errors gracefully - especially for home/splash page
  172. // UNA may return errors for guest users when certain blocks (e.g., messenger)
  173. // try to access user context. The frontend should still render the splash page.
  174. const hasApiError = data?.code === 500 || data?.code === 503;
  175. if (hasApiError && isHomePage) {
  176. console.warn('UNA API error on home page - rendering with fallback data for splash screen');
  177. // Provide minimal fallback data so splash page can render
  178. const fallbackData = {
  179. title: SITE_TITLE,
  180. description: SITE_TITLE,
  181. uri: '/',
  182. url: '/',
  183. page_name: 'home',
  184. page_type: 'home',
  185. logged: 0,
  186. // Empty blocks - splash will render static content
  187. blocks: {}
  188. };
  189. return (
  190. <Suspense fallback={null}>
  191. <Root path={'home'} data={fallbackData} uri={'/'} url={'/'} code={200} />
  192. </Suspense>
  193. );
  194. }
  195. if (data?.data?.page_status == 404) {
  196. notFound(props)
  197. }
  198. return (
  199. <Suspense fallback={null}>
  200. <Root path={path} data={data?.data} uri={data?.data?.uri} url={data?.data?.url} code={data?.code}></Root>
  201. </Suspense>
  202. )
  203. }